// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import 'package:pub/src/crc32c.dart';
import 'package:pub/src/source/hosted.dart';
import 'package:pub/src/utils.dart' show hexEncode;
import 'package:pub_semver/pub_semver.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:test/test.dart';
import 'package:test/test.dart' as test show expect;

import 'descriptor.dart' as d;
import 'test_pub.dart';

class PackageServer {
  /// The inner [shelf_io.IOServer] that this uses to serve its descriptors.
  final shelf_io.IOServer _inner;

  /// Handlers of requests. Last matching handler will be used.
  final List<_PatternAndHandler> _handlers = [];

  // A list of all the requests received up till now.
  final List<String> requestedPaths = <String>[];

  // Setting this to false will disable automatic calculation of content-hashes.
  bool serveContentHashes = true;

  /// Whether the [shelf_io.IOServer] should compress the content, if possible.
  /// The default value is `false` (compression disabled).
  /// See [HttpServer.autoCompress] for details.
  bool get autoCompress => _inner.server.autoCompress;
  set autoCompress(bool shouldAutoCompress) =>
      _inner.server.autoCompress = shouldAutoCompress;

  // Setting this to false will disable automatic calculation of checksums.
  bool serveChecksums = true;

  PackageServer._(this._inner) {
    final outerZone = Zone.current;
    _inner.mount((request) {
      try {
        final path = request.url.path;
        requestedPaths.add(path);
        final pathWithInitialSlash = '/$path';
        for (final entry in _handlers.reversed) {
          final match = entry.pattern.matchAsPrefix(pathWithInitialSlash);
          if (match != null && match.end == pathWithInitialSlash.length) {
            final a = entry.handler(request);
            return a;
          }
        }
        return shelf.Response.notFound('Could not find ${request.url}');
      } catch (e, st) {
        // Because shelf swallows all errors we catch here and redirect to the
        // zone error handler.
        outerZone.handleUncaughtError(e, st);
        _inner.close();
        rethrow;
      }
    });
  }

  static final _versionInfoPattern = RegExp(r'/api/packages/([a-zA-Z_0-9]*)');
  static final _advisoriesPattern = RegExp(
    r'/api/packages/([a-zA-Z_0-9]*)/advisories',
  );

  static final _downloadPattern = RegExp(
    r'/packages/([^/]*)/versions/([^/]*).tar.gz',
  );

  static Future<PackageServer> start() async {
    final server = PackageServer._(
      await shelf_io.IOServer.bind(InternetAddress.loopbackIPv4, 0),
    );
    server.handle(_versionInfoPattern, (shelf.Request request) async {
      final parts = request.url.pathSegments;
      assert(parts[0] == 'api');
      assert(parts[1] == 'packages');
      final name = parts[2];

      final package = server._packages[name];
      if (package == null) {
        return shelf.Response.notFound('No package named $name');
      }

      return shelf.Response.ok(
        jsonEncode({
          'name': name,
          'uploaders': ['nweiz@google.com'],
          'versions': [
            for (final version in package.versions.values)
              {
                'pubspec': version.pubspec,
                'version': version.version.toString(),
                'archive_url':
                    '${server.url}/packages/$name/versions/${version.version}.tar.gz',
                if (version.isRetracted) 'retracted': true,
                if (version.sha256 != null || server.serveContentHashes)
                  'archive_sha256':
                      version.sha256 ??
                      hexEncode(
                        (await sha256.bind(version.contents()).first).bytes,
                      ),
              },
          ],
          if (package.isDiscontinued) 'isDiscontinued': true,
          if (package.advisoriesUpdated != null)
            'advisoriesUpdated': package.advisoriesUpdated!.toIso8601String(),
          if (package.discontinuedReplacementText != null)
            'replacedBy': package.discontinuedReplacementText,
        }),
        headers: {HttpHeaders.contentTypeHeader: 'application/vnd.pub.v2+json'},
      );
    });

    server.handle(_advisoriesPattern, (shelf.Request request) async {
      final parts = request.url.pathSegments;
      assert(parts[0] == 'api');
      assert(parts[1] == 'packages');
      final name = parts[2];
      assert(parts[3] == 'advisories');

      final package = server._packages[name];
      if (package == null) {
        return shelf.Response.notFound('No package named $name');
      }

      return shelf.Response.ok(
        jsonEncode({
          'advisoriesUpdated': defaultAdvisoriesUpdated.toIso8601String(),
          'advisories': [
            for (final advisory in package.advisories.values)
              {
                'id': advisory.id,
                'summary': 'Example',
                'aliases': [...advisory.aliases],
                'details': 'This is a dummy example.',
                'modified': defaultAdvisoriesUpdated.toIso8601String(),
                'published': defaultAdvisoriesUpdated.toIso8601String(),
                'affected': [
                  for (final package in advisory.affectedPackages)
                    {
                      'package': {
                        'name': package.name,
                        'ecosystem': package.ecosystem,
                      },
                      'versions': [...package.versions],
                    },
                ],
                if (advisory.displayUrl != null)
                  'database_specific': {'pub_display_url': advisory.displayUrl},
              },
          ],
        }),
        headers: {HttpHeaders.contentTypeHeader: 'application/vnd.pub.v2+json'},
      );
    });

    server.handle(_downloadPattern, (shelf.Request request) async {
      final parts = request.url.pathSegments;
      assert(parts[0] == 'packages');
      final name = parts[1];
      assert(parts[2] == 'versions');
      final package = server._packages[name];
      if (package == null) {
        return shelf.Response.notFound('No package $name');
      }

      final version = Version.parse(
        parts[3].substring(0, parts[3].length - '.tar.gz'.length),
      );
      assert(parts[3].endsWith('.tar.gz'));

      for (final packageVersion in package.versions.values) {
        if (packageVersion.version == version) {
          final headers = packageVersion.headers ?? {};
          headers[HttpHeaders.contentTypeHeader] ??= [
            'application/octet-stream',
          ];

          // This gate enables tests to validate the CRC32C parser by
          // passing in arbitrary values for the checksum header.
          if (server.serveChecksums &&
              !headers.containsKey(checksumHeaderName)) {
            headers[checksumHeaderName] = composeChecksumHeader(
              crc32c: await packageVersion.computeArchiveCrc32c(),
            );
          }

          return shelf.Response.ok(packageVersion.contents(), headers: headers);
        }
      }
      return shelf.Response.notFound('No version $version of $name');
    });
    return server;
  }

  Future<void> close() async {
    await _inner.close();
  }

  /// The port used for the server.
  int get port => _inner.url.port;

  /// The URL for the server.
  String get url => _inner.url.toString();

  /// From now on report errors on any request.
  void serveErrors() =>
      _handlers
        ..clear()
        ..add(
          _PatternAndHandler(RegExp('.*'), (request) {
            fail(
              'The HTTP server received an unexpected request:\n'
              '${request.method} ${request.requestedUri}',
            );
          }),
        );

  void handle(Pattern pattern, shelf.Handler handler) {
    _handlers.add(_PatternAndHandler(pattern, handler));
  }

  // Installs a handler at [pattern] that expects to be called exactly once with
  // the given [method].
  //
  // The handler is installed as the start to give it priority over more general
  // handlers.
  void expect(String method, Pattern pattern, shelf.Handler handler) {
    handle(
      pattern,
      expectAsync1((request) {
        test.expect(request.method, method);
        return handler(request);
      }),
    );
  }

  /// Returns the path of [package] at [version], installed from this server, in
  /// the pub cache.
  String pathInCache(String package, String version) =>
      p.join(cachingPath, '$package-$version');

  /// The location where pub will store the cache for this server.
  String get cachingPath =>
      p.join(d.sandbox, cachePath, 'hosted', 'localhost%58$port');

  String get hashesCachingPath =>
      p.join(d.sandbox, cachePath, 'hosted-hashes', 'localhost%58$port');

  /// A map from package names to the concrete packages to serve.
  final _packages = <String, _ServedPackage>{};

  /// Specifies that a package named [name] with [version] should be served.
  ///
  /// If [deps] is passed, it's used as the "dependencies" field of the pubspec.
  /// If [pubspec] is passed, it's used as the rest of the pubspec.
  ///
  /// If [contents] is passed, it's used as the contents of the package. By
  /// default, a package just contains a dummy lib directory.
  void serve(
    String name,
    String version, {
    Map<String, dynamic>? deps,
    Map<String, dynamic>? pubspec,
    List<d.Descriptor>? contents,
    String? sdk,
    Map<String, List<String>>? headers,
  }) {
    final pubspecFields = <String, dynamic>{
      'name': name,
      'version': version,
      'environment': {'sdk': sdk ?? '^3.0.0'},
    };
    if (pubspec != null) pubspecFields.addAll(pubspec);
    if (deps != null) pubspecFields['dependencies'] = deps;

    contents ??= [d.libDir(name, '$name $version')];
    contents = [d.file('pubspec.yaml', yaml(pubspecFields)), ...contents];

    final package = _packages.putIfAbsent(name, _ServedPackage.new);
    package.versions[version] = _ServedPackageVersion(
      pubspecFields,
      headers: headers,
      contents: () => tarFromDescriptors(contents ?? []),
    );
  }

  // Mark a package discontinued.
  void discontinue(
    String name, {
    bool isDiscontinued = true,
    String? replacementText,
  }) {
    _packages[name]!
      ..isDiscontinued = isDiscontinued
      ..discontinuedReplacementText = replacementText;
  }

  static final defaultAdvisoriesUpdated = DateTime.fromMicrosecondsSinceEpoch(
    0,
  );

  /// Add a security advisory which affects versions in [affectedPackages].
  void addAdvisory({
    required String advisoryId,
    String? displayUrl,
    DateTime? advisoriesUpdated,
    List<String> aliases = const <String>[],
    required List<AffectedPackage> affectedPackages,
  }) {
    for (final package in affectedPackages) {
      _packages[package.name]!.advisoriesUpdated =
          advisoriesUpdated ?? defaultAdvisoriesUpdated;
      _packages[package.name]!.advisories.putIfAbsent(
        advisoryId,
        () =>
            _ServedAdvisory(advisoryId, affectedPackages, aliases, displayUrl),
      );
    }
  }

  /// Clears all existing packages from this builder.
  void clearPackages() {
    _packages.clear();
  }

  void retractPackageVersion(String name, String version) {
    _packages[name]!.versions[version]!.isRetracted = true;
  }

  /// Useful for testing handling of a wrong hash.
  void overrideArchiveSha256(String name, String version, String sha256) {
    _packages[name]!.versions[version]!.sha256 = sha256;
  }

  Future<String> peekArchiveSha256(String name, String version) async {
    final v = _packages[name]!.versions[version]!;
    return v.sha256 ?? hexEncode((await sha256.bind(v.contents()).first).bytes);
  }

  Future<String?> peekArchiveChecksumHeader(String name, String version) async {
    final v = _packages[name]!.versions[version]!;

    // If the test configured an overriding header value.
    var checksumHeader = v.headers?[checksumHeaderName];

    // Otherwise, compute from package contents.
    if (serveChecksums) {
      checksumHeader ??= composeChecksumHeader(
        crc32c: await v.computeArchiveCrc32c(),
      );
    }

    return checksumHeader?.join(',');
  }

  static List<String> composeChecksumHeader({
    int? crc32c,
    String? md5 = '5f4dcc3b5aa765d61d8327deb882cf99',
  }) {
    final header = <String>[];

    if (crc32c != null) {
      final bytes = Uint8List(4)..buffer.asByteData().setUint32(0, crc32c);
      header.add('crc32c=${base64.encode(bytes)}');
    }

    if (md5 != null) {
      header.add('md5=${base64.encode(utf8.encode(md5))}');
    }

    return header;
  }
}

class _ServedPackage {
  final versions = <String, _ServedPackageVersion>{};
  bool isDiscontinued = false;
  String? discontinuedReplacementText;
  DateTime? advisoriesUpdated;
  final advisories = <String, _ServedAdvisory>{};
}

/// A package that's intended to be served.
class _ServedPackageVersion {
  final Map pubspec;
  final Stream<List<int>> Function() contents;
  final Map<String, List<String>>? headers;
  bool isRetracted = false;
  // Overrides the calculated sha256.
  String? sha256;

  Version get version => Version.parse(pubspec['version'] as String);

  _ServedPackageVersion(this.pubspec, {required this.contents, this.headers});

  Future<int> computeArchiveCrc32c() async {
    return await Crc32c.computeByConsumingStream(contents());
  }
}

class _ServedAdvisory {
  String id;
  List<String> aliases;
  String? displayUrl;
  List<AffectedPackage> affectedPackages;

  _ServedAdvisory(
    this.id,
    this.affectedPackages,
    this.aliases,
    this.displayUrl,
  );
}

class AffectedPackage {
  String name;
  String ecosystem;
  List<String> versions;

  AffectedPackage({
    required this.name,
    this.ecosystem = 'Pub',
    required this.versions,
  });
}

class _PatternAndHandler {
  Pattern pattern;
  shelf.Handler handler;

  _PatternAndHandler(this.pattern, this.handler);
}
